/*
 * Copyright 2017 - 2025 the original author or authors.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see [https://www.gnu.org/licenses/]
 */

package infra.web.view;

import org.jspecify.annotations.Nullable;

import java.io.IOException;
import java.lang.reflect.Array;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.charset.StandardCharsets;
import java.nio.charset.UnsupportedCharsetException;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import infra.beans.BeanUtils;
import infra.http.HttpStatus;
import infra.http.HttpStatusCode;
import infra.lang.Assert;
import infra.util.CollectionUtils;
import infra.util.StringUtils;
import infra.web.HandlerMatchingMetadata;
import infra.web.RequestContext;
import infra.web.RequestContextUtils;
import infra.web.util.UriUtils;

/**
 * View that redirects to an absolute, context relative, or current request
 * relative URL. The URL may be a URI template in which case the URI template
 * variables will be replaced with values available in the model. By default
 * all primitive model attributes (or collections thereof) are exposed as HTTP
 * query parameters (assuming they've not been used as URI template variables),
 * but this behavior can be changed by overriding the
 * {@link #isEligibleProperty(String, Object)} method.
 *
 * <p>A URL for this view is supposed to be an HTTP redirect URL, i.e.
 * suitable for HttpResponse's {@code sendRedirect} method, which
 * is what actually does the redirect if the HTTP 1.0 flag is on, or via sending
 * back an HTTP 303 code - if the HTTP 1.0 compatibility flag is off.
 *
 * <p>Note that while the default value for the "contextRelative" flag is off,
 * you will probably want to almost always set it to true. With the flag off,
 * URLs starting with "/" are considered relative to the web server root, while
 * with the flag on, they are considered relative to the web application root.
 * Since most web applications will never know or care what their context path
 * actually is, they are much better off setting this flag to true, and submitting
 * paths which are to be considered relative to the web application root.
 *
 * <p><b>NOTE when using this redirect view in a Portlet environment:</b> Make sure
 * that your controller respects the Portlet {@code sendRedirect} constraints.
 *
 * @author Rod Johnson
 * @author Juergen Hoeller
 * @author Colin Sampaleanu
 * @author Sam Brannen
 * @author Arjen Poutsma
 * @author Rossen Stoyanchev
 * @author <a href="https://github.com/TAKETODAY">Harry Yang</a>
 * @see #setHttp10Compatible
 * @see #setExposeModelAttributes
 * @see RequestContext#sendRedirect
 * @since 4.0
 */
public class RedirectView extends AbstractUrlBasedView implements SmartView {

  private static final Pattern URI_TEMPLATE_VARIABLE_PATTERN = Pattern.compile("\\{([^/]+?)\\}");

  private boolean http10Compatible = true;

  private boolean exposeModelAttributes = true;

  @Nullable
  private Charset encoding;

  @Nullable
  private HttpStatusCode statusCode;

  private boolean expandUriTemplateVariables = true;

  private boolean propagateQueryParams = false;

  /**
   * Constructor for use as a bean.
   */
  public RedirectView() {
    setExposePathVariables(false);
  }

  /**
   * Create a new RedirectView with the given URL.
   * <p>The given URL will be considered as relative to the web server,
   * not as relative to the current MockContext.
   *
   * @param url the URL to redirect to
   * @see #RedirectView(String)
   */
  public RedirectView(String url) {
    super(url);
    setExposePathVariables(false);
  }

  /**
   * Create a new RedirectView with the given URL.
   *
   * @param url the URL to redirect to
   * @param http10Compatible whether to stay compatible with HTTP 1.0 clients
   */
  public RedirectView(String url, boolean http10Compatible) {
    super(url);
    this.http10Compatible = http10Compatible;
    setExposePathVariables(false);
  }

  /**
   * Create a new RedirectView with the given URL.
   *
   * @param url the URL to redirect to
   * @param http10Compatible whether to stay compatible with HTTP 1.0 clients
   * @param exposeModelAttributes whether or not model attributes should be
   * exposed as query parameters
   */
  public RedirectView(String url, boolean http10Compatible, boolean exposeModelAttributes) {
    super(url);
    this.http10Compatible = http10Compatible;
    this.exposeModelAttributes = exposeModelAttributes;
    setExposePathVariables(false);
  }

  /**
   * Set whether to stay compatible with HTTP 1.0 clients.
   * <p>In the default implementation, this will enforce HTTP status code 302
   * in any case, i.e. delegate to {@code HttpResponse.sendRedirect}.
   * Turning this off will send HTTP status code 303, which is the correct
   * code for HTTP 1.1 clients, but not understood by HTTP 1.0 clients.
   * <p>Many HTTP 1.1 clients treat 302 just like 303, not making any
   * difference. However, some clients depend on 303 when redirecting
   * after a POST request; turn this flag off in such a scenario.
   *
   * @see RequestContext#sendRedirect
   */
  public void setHttp10Compatible(boolean http10Compatible) {
    this.http10Compatible = http10Compatible;
  }

  /**
   * Set the {@code exposeModelAttributes} flag which denotes whether
   * or not model attributes should be exposed as HTTP query parameters.
   * <p>Defaults to {@code true}.
   */
  public void setExposeModelAttributes(final boolean exposeModelAttributes) {
    this.exposeModelAttributes = exposeModelAttributes;
  }

  /**
   * Set the encoding scheme for this view.
   * <p>Default is the request's encoding scheme
   * (which is UTF-8 if not specified otherwise).
   *
   * @throws IllegalCharsetNameException If the given charset name is illegal
   * @throws IllegalArgumentException If the given {@code charsetName} is null
   * @throws UnsupportedCharsetException If no support for the named charset is available
   * in this instance of the Java virtual machine
   */
  public void setEncodingScheme(String encodingScheme) {
    this.encoding = Charset.forName(encodingScheme);
  }

  /**
   * Set the encoding scheme for this view.
   * <p>Default is the request's encoding scheme
   * (which is UTF-8 if not specified otherwise).
   */
  public void setEncoding(@Nullable Charset charset) {
    this.encoding = charset;
  }

  /**
   * Set the status code for this view.
   * <p>Default is to send 302/303, depending on the value of the
   * {@link #setHttp10Compatible(boolean) http10Compatible} flag.
   */
  public void setStatusCode(HttpStatusCode statusCode) {
    Assert.isTrue(statusCode.is3xxRedirection(), () -> "Not a redirect status code: " + statusCode);
    this.statusCode = statusCode;
  }

  /**
   * Whether to treat the redirect URL as a URI template.
   * Set this flag to {@code false} if the redirect URL contains open
   * and close curly braces "{", "}" and you don't want them interpreted
   * as URI variables.
   * <p>Defaults to {@code true}.
   */
  public void setExpandUriTemplateVariables(boolean expandUriTemplateVariables) {
    this.expandUriTemplateVariables = expandUriTemplateVariables;
  }

  /**
   * When set to {@code true} the query string of the current URL is appended
   * and thus propagated through to the redirected URL.
   * <p>Defaults to {@code false}.
   */
  public void setPropagateQueryParams(boolean propagateQueryParams) {
    this.propagateQueryParams = propagateQueryParams;
  }

  /**
   * Whether to propagate the query params of the current URL.
   */
  public boolean isPropagateQueryProperties() {
    return this.propagateQueryParams;
  }

  /**
   * Returns "true" indicating this view performs a redirect.
   */
  @Override
  public boolean isRedirectView() {
    return true;
  }

  /**
   * An ApplicationContext is not strictly required for RedirectView.
   */
  @Override
  protected boolean isContextRequired() {
    return false;
  }

  /**
   * Convert model to request parameters and redirect to the given URL.
   *
   * @see #appendQueryProperties
   * @see #sendRedirect
   */
  @Override
  protected void renderMergedOutputModel(Map<String, Object> model, RequestContext request) throws IOException {
    String targetUrl = createTargetUrl(model, request);

    // Save RedirectModel
    RequestContextUtils.saveRedirectModel(targetUrl, request);

    // Redirect
    sendRedirect(request, targetUrl);
  }

  /**
   * Create the target URL by checking if the redirect string is a URI template first,
   * expanding it with the given model, and then optionally appending simple type model
   * attributes as query String parameters.
   */
  protected final String createTargetUrl(Map<String, Object> model, RequestContext request) {

    // Prepare target URL.
    StringBuilder targetUrl = new StringBuilder();
    String url = getUrl();
    Assert.state(url != null, "'url' not set");

    targetUrl.append(url);

    Charset enc = this.encoding;
    if (enc == null) {
      enc = StandardCharsets.UTF_8;
    }

    if (expandUriTemplateVariables && StringUtils.hasText(targetUrl)) {
      Map<String, String> variables = getCurrentRequestUriVariables(request);
      targetUrl = replaceUriTemplateVariables(targetUrl.toString(), model, variables, enc);
    }
    if (isPropagateQueryProperties()) {
      appendCurrentQueryParams(targetUrl, request);
    }
    if (exposeModelAttributes) {
      appendQueryProperties(targetUrl, model, enc);
    }

    return targetUrl.toString();
  }

  /**
   * Replace URI template variables in the target URL with encoded model
   * attributes or URI variables from the current request. Model attributes
   * referenced in the URL are removed from the model.
   *
   * @param targetUrl the redirect URL
   * @param model a Map that contains model attributes
   * @param currentUriVariables current request URI variables to use
   * @param encodingScheme the encoding scheme to use
   */
  protected StringBuilder replaceUriTemplateVariables(String targetUrl,
          Map<String, Object> model, Map<String, String> currentUriVariables, Charset encodingScheme) {

    StringBuilder result = new StringBuilder();
    Matcher matcher = URI_TEMPLATE_VARIABLE_PATTERN.matcher(targetUrl);
    int endLastMatch = 0;
    while (matcher.find()) {
      String name = matcher.group(1);
      Object value = model.containsKey(name) ? model.remove(name) : currentUriVariables.get(name);
      if (value == null) {
        throw new IllegalArgumentException("Model has no value for key '" + name + "'");
      }
      result.append(targetUrl, endLastMatch, matcher.start());
      result.append(UriUtils.encodePathSegment(value.toString(), encodingScheme));
      endLastMatch = matcher.end();
    }
    result.append(targetUrl.substring(endLastMatch));
    return result;
  }

  private Map<String, String> getCurrentRequestUriVariables(RequestContext request) {
    HandlerMatchingMetadata matchingMetadata = request.getMatchingMetadata();
    if (matchingMetadata != null) {
      return matchingMetadata.getUriVariables();
    }
    return Collections.emptyMap();
  }

  /**
   * Append the query string of the current request to the target redirect URL.
   *
   * @param targetUrl the StringBuilder to append the properties to
   * @param request the current request
   */
  protected void appendCurrentQueryParams(StringBuilder targetUrl, RequestContext request) {
    String query = request.getQueryString();
    if (StringUtils.hasText(query)) {
      // Extract anchor fragment, if any.
      String fragment = null;
      int anchorIndex = targetUrl.indexOf("#");
      if (anchorIndex > -1) {
        fragment = targetUrl.substring(anchorIndex);
        targetUrl.delete(anchorIndex, targetUrl.length());
      }

      if (targetUrl.toString().indexOf('?') < 0) {
        targetUrl.append('?').append(query);
      }
      else {
        targetUrl.append('&').append(query);
      }
      // Append anchor fragment, if any, to end of URL.
      if (fragment != null) {
        targetUrl.append(fragment);
      }
    }
  }

  /**
   * Append query properties to the redirect URL.
   * Stringifies, URL-encodes and formats model attributes as query properties.
   *
   * @param targetUrl the StringBuilder to append the properties to
   * @param model a Map that contains model attributes
   * @param encodingScheme the encoding scheme to use
   * @see #queryProperties
   */
  protected void appendQueryProperties(
          StringBuilder targetUrl, Map<String, Object> model, Charset encodingScheme) {
    // Extract anchor fragment, if any.
    String fragment = null;
    int anchorIndex = targetUrl.indexOf("#");
    if (anchorIndex > -1) {
      fragment = targetUrl.substring(anchorIndex);
      targetUrl.delete(anchorIndex, targetUrl.length());
    }

    // If there aren't already some parameters, we need a "?".
    boolean first = targetUrl.toString().indexOf('?') < 0;

    for (Map.Entry<String, Object> entry : queryProperties(model).entrySet()) {
      Object rawValue = entry.getValue();
      Collection<?> values;
      if (rawValue != null && rawValue.getClass().isArray()) {
        values = CollectionUtils.arrayToList(rawValue);
      }
      else if (rawValue instanceof Collection) {
        values = (Collection<?>) rawValue;
      }
      else {
        values = Collections.singleton(rawValue);
      }
      for (Object value : values) {
        if (first) {
          targetUrl.append('?');
          first = false;
        }
        else {
          targetUrl.append('&');
        }
        String encodedKey = urlEncode(entry.getKey(), encodingScheme);
        targetUrl.append(encodedKey)
                .append('=');

        if (value != null) {
          targetUrl.append(urlEncode(value.toString(), encodingScheme));
        }
      }
    }

    // Append anchor fragment, if any, to end of URL.
    if (fragment != null) {
      targetUrl.append(fragment);
    }
  }

  /**
   * Determine name-value pairs for query strings, which will be stringified,
   * URL-encoded and formatted by {@link #appendQueryProperties}.
   * <p>This implementation filters the model through checking
   * {@link #isEligibleProperty(String, Object)} for each element,
   * by default accepting Strings, primitives and primitive wrappers only.
   *
   * @param model the original model Map
   * @return the filtered Map of eligible query properties
   * @see #isEligibleProperty(String, Object)
   */
  protected Map<String, Object> queryProperties(Map<String, Object> model) {
    LinkedHashMap<String, Object> result = new LinkedHashMap<>();
    for (Map.Entry<String, Object> entry : model.entrySet()) {
      String name = entry.getKey();
      Object value = entry.getValue();
      if (isEligibleProperty(name, value)) {
        result.put(name, value);
      }
    }
    return result;
  }

  /**
   * Determine whether the given model element should be exposed
   * as a query property.
   * <p>The default implementation considers Strings and primitives
   * as eligible, and also arrays and Collections/Iterables with
   * corresponding elements. This can be overridden in subclasses.
   *
   * @param key the key of the model element
   * @param value the value of the model element
   * @return whether the element is eligible as query property
   */
  protected boolean isEligibleProperty(String key, @Nullable Object value) {
    if (value == null) {
      return false;
    }
    if (isEligibleValue(value)) {
      return true;
    }
    if (value.getClass().isArray()) {
      int length = Array.getLength(value);
      if (length == 0) {
        return false;
      }
      for (int i = 0; i < length; i++) {
        Object element = Array.get(value, i);
        if (!isEligibleValue(element)) {
          return false;
        }
      }
      return true;
    }
    if (value instanceof Collection<?> coll) {
      if (coll.isEmpty()) {
        return false;
      }
      for (Object element : coll) {
        if (!isEligibleValue(element)) {
          return false;
        }
      }
      return true;
    }
    return false;
  }

  /**
   * Determine whether the given model element value is eligible for exposure.
   * <p>The default implementation considers primitives, strings, numbers, dates,
   * URIs, URLs etc as eligible, according to {@link BeanUtils#isSimpleValueType}.
   * This can be overridden in subclasses.
   *
   * @param value the model element value
   * @return whether the element value is eligible
   * @see BeanUtils#isSimpleValueType
   */
  protected boolean isEligibleValue(@Nullable Object value) {
    return value != null && BeanUtils.isSimpleValueType(value.getClass());
  }

  /**
   * URL-encode the given input String with the given encoding scheme.
   * <p>The default implementation uses {@code URLEncoder.encode(input, enc)}.
   *
   * @param input the unencoded input String
   * @param encodingScheme the encoding scheme
   * @return the encoded output String
   * @see URLEncoder#encode(String, String)
   */
  protected String urlEncode(String input, Charset encodingScheme) {
    return URLEncoder.encode(input, encodingScheme);
  }

  /**
   * Send a redirect back to the HTTP client.
   *
   * @param context current HTTP request (allows for reacting to request method)
   * @param targetUrl the target URL to redirect to
   * @throws IOException if thrown by response methods
   */
  protected void sendRedirect(RequestContext context, String targetUrl) throws IOException {
    HttpStatusCode httpStatus = getHttpStatus(context, targetUrl);
    if (httpStatus != null) {
      context.setStatus(httpStatus);
      context.responseHeaders().setLocation(targetUrl);
    }
    else {
      context.sendRedirect(targetUrl);
    }
  }

  /**
   * Determines the status code to use for HTTP compatible requests.
   * <p>The default implementation returns the {@link #setStatusCode(HttpStatusCode) statusCode}
   * property if set, or the value of the {@link #RESPONSE_STATUS_ATTRIBUTE} attribute.
   * If neither are set, it defaults to {@link HttpStatus#SEE_OTHER} (303) or {@code null}
   * when {@link #http10Compatible} is true.
   *
   * @param context the request to inspect
   * @param targetUrl the target URL
   * @return the response status
   */
  @Nullable
  protected HttpStatusCode getHttpStatus(RequestContext context, String targetUrl) {
    if (statusCode != null) {
      return statusCode;
    }
    Object attribute = context.getAttribute(View.RESPONSE_STATUS_ATTRIBUTE);
    if (attribute instanceof HttpStatusCode attributeStatusCode) {
      return attributeStatusCode;
    }
    if (http10Compatible) {
      return null;
    }
    return HttpStatus.SEE_OTHER;
  }

}
